from heapq import heappush, heappop, heapify
import math, random
import parole
import main, ai, interface, language
from util import *
from parole.colornames import colors

class Event(object):
    """
    Represents an "event" that occurs at a particular time in the game
    simulation. At minimum, an Event must have an id (a string) and an
    associated time. A Schedule instance is responsible for ensuring that
    Listener instances have the opportunity to handle the Event at its
    associated time in the simulation. 
    
    Optionally, an Event may have associated dests, args, and options. If given,
    dests must be a sequence of Listeners to which the Schedule will deliver the
    event regardless of whether they have registered to listen for events of its
    id. args may be an object of any type, the semantics of which are specific
    to the event id. options may be a dictionary of option name-value pairs,
    which defaults to the empty dict if not given. The difference between args
    and options is that args is meant to encode information required of all
    Event instances of the given id, while options is intended for truly
    optional information that may be relevant across Events of different ids.
    """

    def __init__(self, id, time, args = None, dests = None, options=None):
        if type(id) is not str:
            raise TypeError('id must be a string (got %s instead)' % type(id))
        self.id = id
        self.time = time
        self.args = args
        self.dests = dests and tuple(dests) or ()
        self.options = options or {}
        self.canceled = False

    def __cmp__(self, event):
        return cmp(self.time, event.time)

    def __repr__(self):
        return 'Event(%r, %r, args=%r, dests=%r)' % (self.id, self.time,
                self.args, self.dests)

#========================================

class Listener(object):
    """
    An object that handles events, as encapsulated by Event instances. To handle
    an event with id "foo", implement a method named "handle_foo" accepting the
    event instance as the only required argument. Spaces in the id become
    underscores in the handler method name, e.g., "foo bar" -> "handle_foo_bar".

    The appropriate handler method for an event is automatically called by the
    listen() method. In normal usage, the listen() method is called by a
    Schedule instance either because the Listener has registered with the
    Schedule to listen for events of a particular id, or because an Event was
    added to the Schedule with the Listener explicitly listed in the Event's
    dests attribute.
    """

    def listen(self, event):
        """
        Call this Listener's "handle_" method for the given event, if
        implemented.
        """
        handleMethodName = 'handle_%s' % event.id.replace(' ', '_')
        try:
            method = getattr(self, handleMethodName)
        except AttributeError:
            # no handler for this event
            return
        else:
            method(event)

#========================================

class StopSchedule(Exception):
    """
    Exception raised by a Schedule's advance() method when it has no more
    scheduled Event instances to deliver.
    """
    pass

#========================================

class ScheduleError(Exception):
    """
    Exception raised by a Schedule for any kind of error or inconsistency in
    event scheduling logic.
    """
    pass

#========================================

class Schedule(object):

    # Events that would be too annoying/spammy to print debug messages for
    noDebugEvents = ('think', 'burn', 'regen stamina')

    def __init__(self, startTime = 0):
        self.eventHeap = []
        self.time = startTime
        self.__listeners = {}
        self.eventsSent = []
        self.add(Event('start sim', 0))

    def getTime(self):
        return self.time

    def add(self, event):
        if event.time < self.time:
            raise ValueError('Tried to add an Event with time prior to current '
                             'time. Time travel would violate either (a) the '
                             'equal validity of all intertial frames of '
                             'reference, (b) the constancy of the speed of '
                             'light, or (c) causality. This Schedule instance '
                             'is unwilling to relinquish any of these '
                             'postulates.')
        heappush(self.eventHeap, event)
        if event.id not in self.noDebugEvents:
            parole.debug('Scheduling event %r', event)

    def __call__(self, event):
        self.add(event)

    def remove(self, event):
        parole.debug('Removing event %r', event)
        #if event in self.eventHeap:
        self.eventHeap.remove(event)
        heapify(self.eventHeap)       

    def __contains__(self, event):
        return (event in self.eventHeap)

    def clear(self):
        del self.eventHeap[:]

    def nextEvent(self):
        return self.eventHeap[0]

    def listen(self, id, listener):
        if id not in self.__listeners:
            self.__listeners[id] = set()
        self.__listeners[id].add(listener)

    def unlisten(self, id, listener):
        # let exceptions propagate
        self.__listeners[id].remove(listener)

    def removeListener(self, listener):
        for id, listeners in self.__listeners.iteritems():
            if listener in listeners:
                listeners.remove(listener)

        for event in self.eventHeap:
            if event.dests and listener in event.dests:
                event.dests = \
                        tuple([d for d in event.dests if d is not listener])

    def advance(self):
        self.eventsSent = []

        try:
            event = heappop(self.eventHeap)
        except IndexError:
            raise StopSchedule('No more events in Schedule.')

        self.time = event.time
        self.sendNow(event)
        return self.eventsSent

    def sendNow(self, event):
        listeners = set()
        for l in event.dests or ():
            listeners.add(l)
        try:
            listeners |= self.__listeners[event.id]
        except KeyError:
            pass

        if event.id not in self.noDebugEvents:
            parole.debug('Schedule: sending %r to %r', event, listeners)

        for listener in listeners:
            listener.listen(event)

        self.eventsSent.append(event)

#=========================================

class Obj(parole.map.MapObject, Listener):
    """
    Anything that can be located on a map and handle events.
    """
    
    genders = ['male', 'female', 'neuter']

    def __init__(self, nameRoot, layer, shader, blockLOS=False, blockMove=False,
            startsVowel=False, massNoun=False, gender='neuter',
            pluralNameRoot=None, baseDescription=None,
            blocksNonCorporeal=False, hasProperName=False):
        super(Obj, self).__init__(layer, shader, blocksLOS=blockLOS,
                blocksMove=blockMove)
        self.name = nameRoot
        self.startsVowel = startsVowel
        self.massNoun = massNoun
        if gender not in Obj.genders:
            raise ValueError('bad gender: %s' % gender)
        self.gender = gender
        self.baseDescription = baseDescription
        self.blocksNonCorporeal = blocksNonCorporeal
        self.corporeal = True
        self.hasProperName = hasProperName
        self.flying = False # currently flying through the air?
        self.trajectory = None
        # if true, collisions result in projectile landing in our square rather
        # than adjacent square; should also handle "receive projectile" event
        self.receivesProjectiles = False 
        # can be damaged:
        self.receivesDamage = False

    def __str__(self):
        return self.noun()

    def __repr__(self):
        return '%s <%s>' % (str(self), id(self))

    def description(self):
        return self.baseDescription or 'Rather unremarkable.'

    def nounPrefix(self):
        return ''

    def nounSuffix(self):
        return ''

    def nounAnnotation(self):
        annotation = ''
        if self.flying:
            annotation += " {\DeepPink (flying through the air)}"
        return annotation

    def noun(self, definite=False, article=True, capitalized=False,
            nameOverride=None, plural=False, number=1, annotation=True,
            affixes=True):
        """
        Full article+noun phrase. No article if proper==True.
        """
        # TODO: "something" if player is blind
        root = nameOverride or self.name
        if plural and self.pluralNameRoot:
            root = self.pluralNameRoot
        elif plural:
            if root[-1] in ('s', 'z', 'x') or (len(root) >= 2 and root[-2:] in \
                    ('sh', 'ch')):
                root += 'es'
            elif root[-1] == 'y':
                root = root[:-1] + 'ies'
            else:
                root += 's'

        nounPhrase = ("%s %s %s %s" % \
                ((number > 1 and number or ''), 
                 self.nounPrefix() if affixes else '', 
                 root, 
                 self.nounSuffix() if affixes else '')).strip()

        if self.hasProperName:
            article = False

        if article:
            if definite:
                art = 'the'
            elif number > 1:
                art = ''
            elif plural or self.massNoun:
                art = 'some'
            elif self.startsVowel or nounPhrase[0] in ('a','e','i','o','u'):
                art = 'an'
            else:
                art = 'a'

            nounPhrase = ("%s %s" % (art, nounPhrase)).strip()

        if capitalized:
            nounPhrase = nounPhrase[0].upper() + nounPhrase[1:]

        if annotation:
            return ("%s %s" % (nounPhrase, self.nounAnnotation())).strip()
        else:
            return nounPhrase.strip()

    def pronounNom(self, capitalized=False):
        """
        Nominative pronoun.
        """
        if self.gender == 'male':
            pro = 'he'
        elif self.gender == 'female':
            pro = 'she'
        else:
            pro = 'it'
        if capitalized:
            pro = pro[0].upper() + pro[1:]
        return pro

    def pronounAcc(self):
        """
        Accusative pronoun.
        """
        if self.gender == 'male':
            return 'him'
        elif self.gender == 'female':
            return 'her'
        else:
            return 'it'

    def pronounGen(self, capitalized=False):
        """
        Genitive/possessive pronoun.
        """
        if self.gender == 'male':
            pro = 'his'
        elif self.gender == 'female':
            pro = 'her'
        else:
            pro = 'its'
        if capitalized:
            pro = pro[0].upper() + pro[1:]
        return pro

    def pronounReflexive(self):
        if self.gender == 'male':
            return 'himself'
        elif self.gender == 'female':
            return 'herself'
        else:
            return 'itself'

    def blockedBy(self, other):
        """
        Whether this object is blocked by the given object. Default
        implementation: if self.corporeal, return True iff other.blocksMove,
        otherwise return True iff other.blocksNonCorporeal.
        """
        if self.corporeal:
            return bool(other.blocksMove)
        return bool(other.blocksNonCorporeal)

    def collidesWith(self, other):
        """
        Whether this object can collide with the given object, which is assumed
        to be flying through the air. Default implementation always returns
        True iff self.corporeal and self.blocksMove. 
        """
        return self.corporeal and self.blocksMove

    def collisionMsg(self, other):
        """
        Returns the string of the message for this object colliding into the
        given other object. Default implementation returns "The <self> collides
        with the <other>."
        """
        return "%s collides with %s." % (self.noun(definite=1,
            capitalized=1), other.noun(definite=1))

    def handle_translocate(self, event):
        """
        Handles:
            - "translocate" (obj, dest, destMap)
        """
        obj, dest, destMap = event.args
        if obj is not self:
            return

        curMap = None
        if self.parentTile:
            curMap = self.parentTile.map

        # schedule immediate leave/enter events 
        force = 'force' in event.options and event.options['force']
        for mb in destMap[dest]:
            if mb.blocksMove and (not force) and self.blockedBy(mb):
                # turns out we can't move there
                event.canceled = True
                if obj is main.player:
                    main.transient('%s blocks your way.' %\
                            mb.noun(definite=1, capitalized=1))
                return

        main.schedule.add(Event('leave tile', main.schedule.time,
                (self, self.pos), dests=[self]))
        main.schedule.add(Event('enter tile', main.schedule.time,
                (self, dest, destMap), dests=[self]))
        if curMap:
            curMap[self.pos].remove(self)
            curMap.update()
        destMap[dest].add(self)
        destMap.update()

        if 'transient' in event.options and event.options['transient']\
                and visible(dest):
            main.transient(event.options['transient'], dest)

        if self.blocksMove:
            colliders = []
            for item in destMap[dest]:
               if item.flying and self.collidesWith(item):
                   # we walked into a flying projectile, so it collides
                   # with us
                   colliders.append(item)
            for collider in colliders:
                main.schedule.sendNow(Event('collision',
                   main.schedule.time, args=(collider, self,
                       collider.trajectory), dests=[collider.trajectory,
                           collider, self]))

#=========================================

class Door(Obj):
    """
    Any object that can toggle between blocking and not blocking LOS and
    movement. Automatically does so in response to any 'door' event it
    receives with itself as the first argument. The second argument is one of
    'open', 'close', 'toggle'.
    """
    
    def __init__(self, name, layer, openShader, closedShader, open=False,
            startsVowel=False, massNoun=False, gender='neuter'):
        Obj.__init__(self, name, layer, open and openShader or closedShader,
                blockLOS=not open, blockMove=not open, startsVowel=startsVowel,
                massNoun=massNoun, gender=gender, blocksNonCorporeal=False)
        self.openShader = openShader
        self.closedShader = closedShader
        self.opened = open
        if open:
            self.open()
        else:
            self.close()

    def isOpen(self):
        return self.opened

    def isClosed(self):
        return not self.opened

    def open(self):
        t = self.parentTile
        if t:
            t.remove(self)
        self.opened = True
        self.blocksLOS = False
        self.blocksMove = False
        self.shader = self.openShader
        if t:
            t.add(self)
            t.map.update()

        if main.mapFrame and main.mapFrame.fovObj:
            main.mapFrame.touchFOV()

    def close(self):
        t = self.parentTile
        if t:
            t.remove(self)
        self.opened = False
        self.blocksLOS = True
        self.blocksMove = True
        self.shader = self.closedShader
        if t:
            t.add(self)
            t.map.update()

        if main.mapFrame and main.mapFrame.fovObj:
            main.mapFrame.touchFOV()

    def toggleOpen(self):
        if self.isOpen():
            self.close()
        else:
            self.open()

    def listen(self, event):
        """
        Handles:
            "door" (target, "open"/"close"/"toggle")
        """
        Obj.listen(self, event)

        if event.id == 'door' and event.args[0] is self:
            action = event.args[1]
            if action == 'open':
                self.open()
            elif action == 'close':
                self.close()
            elif action == 'toggle':
                self.toggleOpen()

#=========================================

class Puddle(Obj):
    """
    Object representing some quantity of uncontained liquid at a map location.
    """
    def __init__(self, amount, name, baseName, char, fg_rgb, bg_rgb):
        super(Puddle, self).__init__(name, 1, parole.map.AsciiTile(char, fg_rgb,
            bg_rgb=bg_rgb))
        self.amount = amount
        self.baseName = baseName

class Splatter(object):
    """
    An abstract manager of how a fixed amount of liquid "spreads" across a map.
    """
    bigAmount = 6

    def __init__(self, puddleClass, amount):
        self.puddleClass = puddleClass
        self.amount = amount
        self.map = None

    def __blocksPuddle(self, tile):
        for obj in tile:
            if (not obj.receivesProjectiles) and obj.blocksMove:
                return True
        return False

    def __firstPuddleIn(self, pos):
        for obj in self.map[pos]:
            if isinstance(obj, self.puddleClass):
                return obj
        return None

    def __addPuddleTo(self, puddle, pos):
        main.schedule.sendNow(Event('enter tile', main.schedule.time,
                (puddle, pos, self.map), dests=[puddle]))
        self.map[pos].add(puddle)
        main.schedule.sendNow(Event('splash', main.schedule.time, (puddle, pos,
            self.map), dests=self.map[pos].contents))

    def __removePuddle(self, puddle):
        main.schedule.sendNow(Event('leave tile', main.schedule.time,
                (puddle, puddle.pos), dests=[puddle]))
        puddle.parentTile.remove(puddle)
        
    def apply(self, tile, slope, maxDist, **kwargs):
        if self.__blocksPuddle(tile):
            # don't bother if the target tile blocks puddles
            return

        self.map = tile.map
        tX, tY = tile.col, tile.row
        amountLeft = self.amount
        minDist = 0.5 * maxDist
        xInc, yInc = slope
        tries = 100
        while tries and amountLeft > 0:
            #parole.debug('a -- slope = %r', (xInc, yInc))
            tries -= 1
            dist = minDist + random.random()*(maxDist/2.)
            x, y = float(tX), float(tY)
            while amountLeft > 0:
                #parole.debug('b -- amountLeft = %d', amountLeft)
                if distance((tX, tY), (x,y)) <= dist:
                    #parole.debug('c')
                    mX, mY = int(round(x)), int(round(y))
                    if mX >= self.map.cols or mY >= self.map.rows or mX < 0 or\
                            mY < 0 or self.__blocksPuddle(self.map[mX, mY]):
                        break
                    existingPuddle = self.__firstPuddleIn((mX, mY))
                    if existingPuddle:
                        newAmount = existingPuddle.amount + 1
                        if newAmount >= self.bigAmount:
                            newPuddle = self.puddleClass(newAmount, big=True,
                                    **kwargs)
                            #self.map[mX, mY].remove(existingPuddle)
                            self.__removePuddle(existingPuddle)
                            self.__addPuddleTo(newPuddle, (mX, mY))
                        else:
                            existingPuddle.amount = newAmount
                    else:
                        puddle = self.puddleClass(1, **kwargs)
                        #self.map[mX, mY].add(puddle)
                        self.__addPuddleTo(puddle, (mX, mY))

                    amountLeft -= 1
                    x += xInc
                    y += yInc
                else:
                    #parole.debug('d')
                    break
            xInc = slope[0] + random.normalvariate(0.0, 0.5)
            yInc = slope[1] + random.normalvariate(0.0, 0.5)

        self.map = None

#=========================================

class Floor(Obj):
    """
    Any floor should be an instance of this if it wants to be able to have
    items/monsters placed at it, or have thrown objects collide with it.
    """
    pass

#=========================================

class Trajectory(Listener):
    """
    Manages and handles events for propelling objects ballistically across
    the map.
    """

    gravity = 6.43 # downward acceleration due to gravity, in tiles per sec^2.

    @classmethod
    def maxRangeOf(cls, velocity, initialHeight=1.0):
        timeToFloor = math.sqrt(2.*initialHeight/cls.gravity)
        # FIXME: assumes constant forward velocity (no drag)
        return velocity * timeToFloor

    @staticmethod
    def velocityAtEnergy(energy, mass):
        return math.sqrt(2.*energy/mass)

    def __init__(self, item, liableParty=None):
        super(Trajectory, self).__init__()
        self.item = item
        self.currentVelocity = None
        self.path = None
        self.currentHeight = None
        self.maxRange = None
        self.liableParty = liableParty
        
    def start(self, srcTile, destTile, time, v0, initialHeight=1.0):
        """
        Propels this Trajectory's item from the given source tile starting at
        the given time, with the given initial velocity. Initiates a chain of
        "fly" (item, srcPos, destPos) events that cause the object to advance
        across the map. If a move-blocker is encountered along the way, the
        object stops moving and a "collision" (item, target) event is sent. If
        no move blockers are encountered, a collision event is issued when the
        trajectory hits the floor.
        """
        srcPos = (srcTile.col, srcTile.row)
        destPos = (destTile.col, destTile.row)
        parole.debug('Propelling %r from %r to %r with initial velocity %r at time '\
                '%d.' % (self.item, srcPos, destPos, v0, time))
        # our bresenham line
        self.line = list(parole.map.bresenhamPoints(srcPos, destPos))

        if self.line[-1] != srcPos:
            self.line.reverse()

        self.path = list(reversed(self.line))

        assert(self.line[-1] == srcPos)
        srcPos = self.line.pop()

        # immediately start flying to the next tile
        self.item.flying = True
        self.item.trajectory = self
        main.schedule.add(Event('fly', time,
            args=(self.item, srcPos, self.line.pop(), self), 
            dests=[self.item, self]))
        self.originPos = srcPos

        self.currentVelocity = v0
        self.currentHeight = initialHeight

        # what is the max range of this trajectory (possibly short of the
        # destination)?
        self.maxRange = Trajectory.maxRangeOf(v0, initialHeight)
        parole.debug('Maximum forward range of trajectory: %f tiles.' %\
                self.maxRange)

        self.collided = False
        main.pushSimAnimation()
        
    def handle_fly(self, event):
        """
        fly             (item, sourcePos, destPos, trajectory)
        """
        if self.collided or event.args[0] is not self.item:
            return
        item, srcPos, destPos, trajectory = event.args
        if trajectory is not self:
            return

        # see if we can fly into the destination pos. if there's a move
        # blocker there, we send a collision event and stop moving,
        # otherwise we move there.
        destTile = item.parentTile.map[destPos]
        for obj in destTile:
            if not obj.corporeal:
                # fly straight through non-corporeal objects
                if visible(obj):
                    main.transient("%s passes straight through %s."\
                            % (self.item.noun(definite=1, 
                                capitalized=1),
                                obj.noun(definite=1)),
                            obj.pos)
                continue 

            if obj.collidesWith(self):
                if obj.receivesProjectiles or not obj.blocksMove:
                    # if we collide with a receiver, stop in its square -
                    # otherwise we stop in the square just before the
                    # collider
                    main.schedule.sendNow(Event('translocate', main.schedule.time,
                        args=(self.item, destPos, item.parentTile.map),
                        dests=[self.item, self], options={'force':1}))

                main.schedule.sendNow(Event('collision', main.schedule.time,
                    args=(self.item, obj, self), dests=[self, self.item,
                        obj]))
                return

        main.schedule.sendNow(Event('translocate', main.schedule.time,
            args=(self.item, destPos, item.parentTile.map),
            dests=[self.item, self]))

        if (not self.line) or distance(destPos, self.path[0]) >=\
                self.maxRange:
            # we reached destination
            # find the floor and collide with it
            for obj in item.parentTile.map[destPos]:
                if isinstance(obj, Floor):
                    main.schedule.sendNow(Event('collision',
                        main.schedule.time, args=(self.item, obj, self),
                        dests=[self, self.item, obj]))
                    return
            # TODO: no floor - fall through to next level or something
            return 

        # schedule our flight into the next tile in the trajectory
        nextPos = self.line.pop()
        time = distance(destPos, nextPos) / self.currentVelocity
        time = int(round(time*1000)) # velocity is in tiles per sec, time is
                                     # in ms.
        main.schedule.add(Event('fly', main.schedule.time + time,
            args=(self.item, destPos, nextPos, self), dests=[self.item, self]))

    def handle_collision(self, event):
        """
        collision       (item, target, trajectory)
        """
        if event.args[0] is not self.item:
            return

        main.popSimAnimation()
        self.collided = True
        item, obj, traj = event.args

        item.flying = False
        item.trajectory = None

        if visible(obj):
            main.transient(item.collisionMsg(obj), item.pos)

